想念你的笑,想念你的外套
知道怎麼處理事件後,再加上狀態記憶,就能夠做許多有趣的互動。
這次我們不沿用 day-1-hello-react,建一個新的專案處理吧。
專案我們就命名為 drink-order,一樣使用 React Router 建立。
npx create-react-router@latest src/drink-order
cd src/drink-order
npm install
npm run dev
專案主要是飲料點餐的情境,這次會建立兩個畫面,分別是
先從點餐時最基本的需求開始吧:記錄選了幾杯紅茶。畫面一開始顯示「已選 0 杯」,每按一次「加一杯」,杯數就增加,畫面也更新的數字。
最直覺的做法,就是用一般變數記錄,在點擊時將變數加一,來看變數的值改變,React 畫面是否會跟著更新嗎。
首先建立紅茶杯數的元件命名為 VariableCounter,在元件函式裡用 quantity 記錄杯數,初始值設為 0,並準備「加一杯」的處理函式:
// 示意片段:放在元件函式裡。
let quantity = 0;
function handleAdd() {
quantity += 1;
console.log("[一般變數] quantity:", quantity);
}
// 示意片段:元件回傳的畫面。
return (
<section>
<p>已選 {quantity} 杯</p>
<button type="button" onClick={handleAdd}>
加一杯
</button>
</section>
);
上面的片段組成元件後,在沒有存檔或其他重新渲染的情況下,連按三次,Console 依序出現 1、2、3,畫面仍顯示「已選 0 杯」。
這是因為掛在按鈕上的處理函式,能讀寫同一次元件呼叫建立的 quantity,所以 Log 可以繼續增加,但修改這個區域變數,沒有通知 React 重新計算畫面。
一開始回傳的 JSX 已經用 0 描述文字,變數後來改了,既有文字不會自動跟著改,就算之後如果 React 因為其他原因再次呼叫 VariableCounter,let quantity = 0 又會建立新的區域變數,無法把這個值當成跨次渲染的記憶。
這時我們需要兩個功能:
保留資料,以及要求 React 用新資料重新渲染。
就算把 quantity 移到元件外面也沒有完成這兩件事:普通變數的修改仍不會主動通知 React 重新計算畫面,還會變成多個元件可能讀寫的共用資料。
從前面的比較發現,元件需要有個地方幫忙保留資料,並在資料更新後重新渲染畫面,這時候就可以用 React 的 Hook。
Hook 就是讓函式元件使用 React 功能的特殊函式。Hook 翻譯是「鉤子」,可以理解成「接上 React 功能的入口」。
Hook 相關功能的名稱以 use 開頭,後面接大寫字母,例如 useState。這是命名習慣,state 則是元件記住的資料。**
這次要使用的 useState,就是Hook的其中一種,用來讓元件使用 React 管理的狀態。
看這裡的名詞解釋:
| 名稱 | 意思 | 點餐情境 |
|---|---|---|
| Hook | 使用 React 功能的特殊函式 | 用 useState 取得杯數與更新函式 |
| 副作用(side effect) | 回傳結果以外,對外產生的可觀察影響 | 把訂單送到伺服器,或把杯數寫入瀏覽器儲存空間 |
useEffect |
用來宣告 Effect 的一個 Hook,讓元件與外部系統同步 | 要讓瀏覽器分頁標題隨杯數同步,可用 Effect 更新 document.title |
副作用可以發生在事件處理函式裡,例如按「送出訂單」後才發送請求。
若需求是隨元件的狀態持續與外部系統同步,才考慮 useEffect 讓 React 在提交畫面更新後執行同步工作。
useEffect是一個 Hook,而副作用不一定透過useEffect執行。
可以看這篇:React:Synchronizing with Effects
不是說元件函式不能修改呼叫前就存在的外部變數或物件,為什麼現在用Hook就可以了?
這裡是我自己疑惑的地方,Hook 可以把元件內的資料送出去然後更新畫面,副作用也是除了回傳結果,也同時對外產生可觀察的影響,那有什麼差別?
對照元件純粹性的重點:
接著看程式運作流程。
useState() 的 Hook 在事件中提出更新:
事件發生 → 事件處理函式算出新值 → setQuantity 提出更新 → React 重新渲染 → useState 提供新的 state → 計算畫面 → 更新顯示
非純粹元件,在渲染中修改外部資料:
開始渲染 -> 直接修改既有的外部變數(副作用)-> React 用修改後的值計算畫面 -> 更新顯示
從最後的流程來看,可以發現 Hook 是讓 React 在渲染時,取資料再更新。非純粹元件則是會在渲染時,直接修改到既有的外部變數(副作用),導致重複渲染,且可能再次改動資料。
從一般程式設計的定義來看,在事件裡呼叫 setQuantity 會提出狀態更新,影響之後的執行,就是副作用。但 React 要求的是「渲染時保持純粹」,也就是元件保持純粹,而事件處理函式可以執行更新資料等副作用。
回到飲料點餐,當動作是:
useState 取得這次的 state,定義 handleAdd,再回傳 JSX。定義 handleAdd 不會執行它裡面的 setQuantity,把它傳給 onClick 也不會。handleAdd,由它呼叫 setQuantity 提出更新。這個事件處理函式可以有副作用。useState 提供更新後的 state,元件再用新的輸入計算 JSX。單純多渲染一次,不會因此多執行一次「加一杯」。元件的渲染契約,是同一組 props、state 與 context,應得到相同的畫面描述,且計算過程不改動外部共用資料。 state 也是這次渲染的輸入,即使資料來自 useState,仍要當成唯讀,不能在渲染時直接改寫其中的物件。
接著使用 useState,讓元件記住杯數,並取得更新 state 的函式。先在檔案頂端匯入:
import { useState } from "react";
useState 是執行時要呼叫的函式,所以使用一般匯入,不能寫成 import type。接著把元件裡的 let quantity = 0 改成:
const [quantity, setQuantity] = useState(0);
| 片段 | 意思 |
|---|---|
0 |
初始值 |
quantity |
這次渲染讀到的飲料杯數 |
setQuantity |
更新函式,把新的值交給 React |
[quantity, setQuantity] |
依順序接收兩個回傳值。不是物件解構,也不是兩個 state |
名稱可以自己取,通常成對命名為 something 與 setSomething。
const 表示這次函式執行中的 quantity 不能重新賦值。但其實我們沒有直接讓 quantity = 1,而是呼叫 setQuantity(1),讓 React 記住新的值。下次呼叫元件時,useState 再把新的值交給那次渲染的 quantity。
同一個元件持續留在畫面上時,重新渲染不會因為又執行 useState(0) 就把值歸零。 因為 0 是初始化時使用的值。
接著直接看實作片段更清楚怎麼使用 useState() 吧。
我們拿這篇最前面一般變數的程式片段來修改,但這次透過 setQuantity 把新的值交給 React:
// 示意片段
function DrinkCounter() {
const [quantity, setQuantity] = useState(0);
function handleAdd() {
setQuantity(quantity + 1);
}
function handleClear() {
setQuantity(0);
}
// ...略
// return ...略
}
「加一杯」把目前杯數加一,「清空杯數」則歸零。再把兩個處理函式分別接到按鈕:
// 示意片段:省略元件外層與樣式。
<p>已選 {quantity} 杯</p>
<button type="button" onClick={handleAdd}>
加一杯
</button>
<button type="button" onClick={handleClear}>
清空杯數
</button>
稍後會建立 DrinkOrder 點餐頁面,在裡面使用兩次相同的計數器,並透過 name prop 傳入飲料名稱:
<DrinkCounter name="紅茶" />
<DrinkCounter name="綠茶" />
紅茶與綠茶使用相同的計數器元件,各自從 0 杯開始。按紅茶的「加一杯」三次,再按綠茶的一次,預期分別顯示 3 杯與 1 杯。
這裡的 name 是外層元件傳入的飲料名稱,quantity 是各個計數器自己的 state。即使傳入相同名稱,只要是畫面上兩個位置的元件,狀態仍各自保留。
重複使用元件,不代表共用同一份 state。React:State is isolated and private
放在函式元件的最外層呼叫 useState,並在任何可能提早結束函式的 return 之前。
以上面的範例來看,對照表格:
| 位置 | 合法 |
|---|---|
DrinkCounter 函式內,事件處理函式與 return 之前 |
正確,讓元件每次渲染都依固定順序呼叫 |
handleAdd 裡 |
錯誤,事件發生時呼叫的是更新函式,不是在那裡新增 state |
if、迴圈或 map() 的回呼裡 |
錯誤,不能讓 useState 的呼叫順序隨條件或筆數改變 |
DrinkCounter 外面、任何元件函式之外 |
錯誤,不能用這個方式建立全域 state |
我們預計流程會是這樣:
第一次顯示:quantity 是 0,畫面顯示「已選 0 杯」
-> 操作「加一杯」
-> handleAdd 呼叫 setQuantity(quantity + 1),這次交出 1
-> React 用更新後的 state 再次呼叫元件
-> 這次 quantity 是 1,畫面顯示「已選 1 杯」
事件處理負責回應操作,setQuantity 提出 state 更新,JSX 再使用 state 描述畫面。
每次點擊只更新一次,會「算出下一個值,再交給更新函式」。
沿用框架預設入口的 StrictMode 開發檢查,在範例中按一次只會增加一次。不要把元件本體可能的額外執行,解讀成點擊被執行兩次。
組合完整程式時,DrinkCounter 與 DrinkDetails 都要在自己的函式最外層呼叫 useState;元件定義則放在 DrinkOrder 外面。將來用清單產生多款飲料的介紹時,也是在 map() 中回傳 <DrinkDetails />,讓每個元件自己宣告 state,而不是直接在 map() 的回呼內呼叫 useState。
一個元件可以宣告不只一個 state,只要遵守呼叫位置與順序,也可以用在自訂 Hook 中。
useState 不只可以記數字,也可以使用在布林值上,這裡我們則拿來記住「目前是否展開介紹」。
接著做一個DrinkDetails元件,讓它接收 name 與 description,並在元件函式裡宣告:
const [isExpanded, setIsExpanded] = useState(false);
function handleToggle() {
setIsExpanded(!isExpanded);
}
畫面則根據 isExpanded 決定按鈕文字與介紹是否顯示:
// 示意片段:省略元件外層與樣式。
<button
type="button"
aria-expanded={isExpanded}
onClick={handleToggle}
>
{isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}
useState(false) 讓介紹一開始是收合的。
!isExpanded 把布林值反轉:false 變成 true,再按一次則變回 false。按鈕文字和介紹內容都讀取同一個 state,所以會一起變化。
這裡用三元運算子決定要顯示介紹段落,還是回傳 null。aria-expanded 也會隨著 state 更新,讓輔助工具知道按鈕控制的內容目前是否展開。
之後組合頁面時,會將飲料名稱和介紹內容透過 props 傳入:
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
兩款飲料的介紹各有自己的 isExpanded。展開紅茶介紹,不會讓綠茶介紹一起展開;收合只會隱藏該款飲料的介紹文字,飲料名稱、杯數和按鈕仍留在畫面上。
外層元件提供名稱和介紹內容,DrinkDetails 自己記住是否展開。DrinkCounter 則管理杯數,兩種操作各自使用需要的 state。
現在把剛才的片段組成可以執行的畫面。
首先在專案中新增 app/practice 資料夾,再依照下面的順序新增或替換檔案。
| 檔案 | 操作 | 用途 |
|---|---|---|
app/practice/variable-counter.tsx |
新增 | 用一般變數記錄紅茶杯數 |
app/practice/drink-order.tsx |
新增 | 飲料計數器、介紹與點餐首頁 |
app/routes/home.tsx |
替換 | 讓首頁顯示 DrinkOrder |
app/routes/variable.tsx |
新增 | 顯示一般變數觀察頁 |
app/routes.ts |
替換 | 設定首頁與 /variable 的路由 |
app/practice/variable-counter.tsx// 文章最前面的觀察範例:一般變數可以改值,但不會通知 React 更新畫面。
export function VariableCounter() {
let quantity = 0;
function handleAdd() {
quantity += 1;
console.log("[一般變數] quantity:", quantity);
}
return (
<section>
<h2>紅茶杯數(一般變數)</h2>
<p>已選 {quantity} 杯</p>
<button type="button" onClick={handleAdd}>
加一杯
</button>
</section>
);
}
app/practice/drink-order.tsximport { useState } from "react";
import { Link } from "react-router";
type DrinkCounterProps = {
name: string;
};
function DrinkCounter({ name }: DrinkCounterProps) {
const [quantity, setQuantity] = useState(0);
function handleAdd() {
setQuantity(quantity + 1);
}
function handleClear() {
setQuantity(0);
}
return (
<div>
<h2>{name}</h2>
<p>已選 {quantity} 杯</p>
<div>
<button type="button" onClick={handleAdd}>
加一杯
</button>
<button type="button" onClick={handleClear}>
清空杯數
</button>
</div>
</div>
);
}
type DrinkDetailsProps = {
name: string;
description: string;
};
function DrinkDetails({ name, description }: DrinkDetailsProps) {
const [isExpanded, setIsExpanded] = useState(false);
function handleToggle() {
setIsExpanded(!isExpanded);
}
return (
<div>
<button
type="button"
aria-expanded={isExpanded}
onClick={handleToggle}
>
{isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}
</div>
);
}
export function DrinkOrder() {
return (
<main>
<header>
<p>第二階段 · DAY 9</p>
<h1>飲料點餐練習</h1>
<p>先選杯數,再看看飲料介紹。</p>
<Link to="/variable">比較一般變數</Link>
</header>
<div>
<section>
<DrinkCounter name="紅茶" />
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
</section>
<section>
<DrinkCounter name="綠茶" />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
</section>
</div>
</main>
);
}
DrinkCounter 與 DrinkDetails 都定義在 DrinkOrder 外面。DrinkOrder 把同一款飲料的杯數和介紹放在同一個區塊。Link 用來切換兩個練習頁面,杯數與介紹的狀態由各自的元件管理。
app/routes/home.tsximport type { Route } from "./+types/home";
import { DrinkOrder } from "../practice/drink-order";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Day 9|飲料點餐練習" },
{ name: "description", content: "用 state 記住飲料杯數與介紹的展開狀態。" },
];
}
export default function Home() {
return <DrinkOrder />;
}
app/routes/variable.tsximport { Link } from "react-router";
import type { Route } from "./+types/variable";
import { VariableCounter } from "../practice/variable-counter";
export function meta({}: Route.MetaArgs) {
return [{ title: "Day 9|一般變數觀察" }];
}
export default function VariablePage() {
return (
<main>
<header>
<p>DAY 9 · 一般變數觀察</p>
<h1>杯數變了,畫面呢?</h1>
<p>
開啟瀏覽器 Console,按三次「加一杯」,比較訊息中的數字與畫面上的杯數。
</p>
</header>
<VariableCounter />
<Link to="/">回到飲料點餐</Link>
</main>
);
}
app/routes.tsimport { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"), // 首頁訂在 home.tsx
route("variable", "routes/variable.tsx"), // 新的頁面,會導向 variable.tsx
] satisfies RouteConfig;
這一步完成後,首頁 / 會顯示飲料點餐,/variable 會顯示一般變數觀察頁。
新增路由後,若編輯器暫時找不到
./+types/variable,可在專案資料夾執行npm run typecheck重新產生型別。
全部存檔後,重新載入首頁,應該會看到紅茶和綠茶的點餐區塊。
按「比較一般變數」會進入觀察頁。
今天先這樣吧,其實還要除錯練習,之後回來補。![]()